Skip to content

feat: add stable agent error contract - #32

Open
chaim0m wants to merge 8 commits into
mainfrom
codex/dci-agent-errors
Open

feat: add stable agent error contract#32
chaim0m wants to merge 8 commits into
mainfrom
codex/dci-agent-errors

Conversation

@chaim0m

@chaim0m chaim0m commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • map HTTP and CLI failures to a finite, documented exit-code contract for explicit agent sessions
  • emit exactly one structured JSON error on stderr in explicit agent mode
  • suppress Cobra usage/error chatter when a structured agent error is returned
  • preserve retry, request ID, HTTP status, and authentication guidance when available
  • preserve legacy formatting and exit behavior for ordinary piped or redirected CLI use

Part of #12. API idempotency keys remain a server-side follow-up.

Test methods

Automated validation run on this branch:

go test ./...
go vet ./...

Manual validation:

go build -o /tmp/dci-pr32 .
DCI_AGENT_MODE=1 DCI_API_KEY=invalid /tmp/dci-pr32 list-budgets --output json
echo $?
DCI_API_KEY=invalid /tmp/dci-pr32 --no-agent list-budgets --output json > /tmp/dci-pr32-error.json
echo $?
cat /tmp/dci-pr32-error.json

The explicit agent command should produce one JSON error on stderr and a specific non-zero exit code. The redirected non-agent command should retain the API response body in the output file and keep the pre-existing CLI exit behavior.

Could this break things?

Risk: medium, limited to explicit agent mode. Scripts using --agent, DCI_AGENT_MODE=1, or a detected agent environment may now receive specific failure exit codes instead of 1 and a JSON error envelope instead of duplicate human-readable stderr. Ordinary redirection, pipes, CI use, API requests, and successful output retain their previous behavior.

Example contract:

{"error":{"code":"AUTHENTICATION_FAILED","message":"...","hint":"Run: dci login","retryable":false,"http_status":401}}

@chaim0m
chaim0m marked this pull request as ready for review August 2, 2026 13:38
@chaim0m
chaim0m requested a review from apgiorgi as a code owner August 2, 2026 13:38
Comment thread main.go Outdated
Comment thread main.go
Comment thread error_contract.go
@chaim0m
chaim0m requested a review from taltultc August 2, 2026 15:33
@chaim0m chaim0m self-assigned this Aug 3, 2026
@apgiorgi

apgiorgi commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Re-reviewed at 8a441db. Built the branch and tested against the live API.

The human-mode blast radius is fixed. c3244d7 scoping the contract to agent mode was the important change — a 401 in human mode is back to exit 4, matching main. Agent mode gives a clean single envelope on stderr:

$ DCI_AGENT_MODE=1 DCI_API_KEY=invalid dci list-budgets --output json
# stdout: (empty)
# stderr: {"error":{"code":"AUTHENTICATION_FAILED","message":"invalid token","hint":"Run: dci login","retryable":false,"http_status":401,"request_id":"a255d5551bd78628-ZRH"}}
# exit 10

The envelope and the code table match issue #12 faithfully. Two things still open.

1. The docs say "explicit" but the gate is agentMode, which includes the soft non-TTY signal

README.md:95 now reads:

Explicit agent sessions emit machine-readable JSON errors with stable error codes, retry guidance, and distinct process exit codes

But the contract is gated on agentMode, and per the detection rules right below it (README:109), non-TTY stdout turns agent mode on as a soft signal. So piped invocations get the contract without asking for it. Verified with no agent flag or env var set:

$ DCI_API_KEY=invalid dci list-budgets --output json > out.json ; echo $?
10                      # main: 4
$ cat out.json
                        # empty — main wrote {"error": "invalid token"}

That's the population most likely to have hard-coded expectations: CI jobs and shell pipelines. Two coherent options — narrow the gate to explicit --agent/DCI_AGENT_MODE=1 only, or keep the current gate and reword the doc to say it applies to piped/redirected output too. Right now the behavior and the documentation disagree, and I'd rather we pick deliberately than leave it implicit.

Related, and worth a separate decision: suppressing next.Format on 4xx/5xx means the API's error body never reaches stdout in agent mode. responseErrorMessage only extracts message/detail/error_description/error.*, so a 400 carrying {"errors":[{field,reason},...]} collapses to the generic "DoiT API request failed with HTTP status 400" and the detail is unrecoverable. Keeping the body on stdout and adding the envelope on stderr would give agents both.

2. There's still no exit-code table

Issue #12's first acceptance criterion is a documented table treated as a stable contract. The README addition is one prose line saying "distinct process exit codes" without saying what they are, and SKILL.md gets two lines. The README already has an Agent Mode section and the skill is compiled into the binary — both are the right homes for the actual table plus the envelope schema. An undocumented contract isn't a contract, and agents reading the embedded skill have no way to learn it.

Smaller notes

  • error_contract.go:110-131 — usage-error detection by English substring match ("accepts ", "invalid argument", "requires at least") couples the exit-code contract to Cobra's unversioned message strings. It already forced a user-visible message change to make the matcher fire. Cobra exposes SetFlagErrorFunc and returns typed args errors; driving exit 2 off that would be sturdier.
  • error_contract.go:94-98 — the *url.Error check is redundant, *url.Error already implements net.Error.
  • Four unused extension pointsagentErrorDescriptor, ExitCode(), Silent(), StructuredError(). Nothing in this repo, restish v0.21.2, or cobra implements any of them; only the test fixture does. isSilentExecutionError is unreachable in practice. Worth trimming unless feat: add command catalog and destructive safeguards #34/fix: improve local command discovery #36 are about to use them.
  • Global mutable stateresponseExitCode and agentErrorWritten are written from inside a formatter and read from run(). Consistent with the existing nonJSONErrorResponse pattern, so not a new sin, but acceptDoerLoginValidation exists precisely because the internal validate run leaks this state, and that will keep biting as more internal sub-runs get added.

Ordering

#36 is standalone now, so this can go after it. Note that #34's ExitCode() 30 and DESTRUCTIVE_REQUIRES_CONFIRMATION envelope are inert until this lands — a blocked destructive currently exits 1 with a Cobra usage dump. And #36's earlier unknown-command work would have duplicated agentErrorDescriptor here; worth checking there's one envelope type and one code table if that comes back.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants